计算机硬件随着时间不断发展,但是核心问题一直存在:CPU、内存、I/O 设备,三者速度有数量级的差距,为了平衡三者的速度差异,主要有以下几点措施:

  1. CPU 与内存之间增加了高速缓存,均衡 CPU 与内存差异
  2. 操作系统提出了进程、线程,用于分时复用 CPU,均衡 CPU 与 I/O 设备差异
  3. 编译器优化指令执行顺序,充分利用缓存(CPU 寄存器,CPU 高速缓存)

这些措施虽然平衡了三者速度的差异,但也带来了问题

# 缓存导致的可见性问题

多线程环境下,一个线程修改了共享变量的值,其他线程能够立即看到,称为可见性(Visibility)

  • 单核场景下:所有线程在一个 CPU 上执行,所有线程都操作同一个 CPU 缓存,因此不存在可见性问题
  • 多核环境下:每个 CPU 内核拥有自己的缓存,当多个线程在不同 CPU 上运行且涉及同一共享变量时,A 线程对 CPU-1 缓存的操作,对 CPU-2 上运行的 B 线程就不具备可见性了,也称为缓存一致性问题

因此,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,来维护缓存的一致性,比如 MESI 协议

/** 
 * 错误案例:程序永远不会打印 end
 *
 * 解决方案:使用 volatile 修饰 flag 保证可见性
 */
public class VolatileTest {
    public static boolean flag = false;

    public static void main(String[] args) throws Exception {
        new Thread(() -> {
            System.out.println("waiting....");
            while (!flag) {
            }
            System.out.println("end");
        }).start();

        Thread.sleep(2000);

        new Thread(() -> {
            flag = true;
            System.out.println("flag changed");
        }).start();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# 线程切换导致的原子性问题

一个或多个操作在 CPU 执行过程中不被中断的特性称为原子性(Atomicity)

早期 OS 基于进程调度 CPU,现代 OS 通常基于更轻量级的线程来调度

对于高级语言来说,一条指令往往需要多条 CPU 指令来执行,例如 counter += 1 需要 3 条指令来完成:

  1. 将变量 counter 从内存加载到 CPU 寄存器
  2. 在寄存器中执行 +1 操作
  3. 将寄存器结果写回内存中(或写入 CPU 缓存中)

CPU 在进行任务调度时,不同任务的切换可能发生在任何一条 CPU 指令之后,因此当两个线程同时执行 counter += 1 时,可能出现下列情景:

  1. counter 初始化为 0
  2. A 线程在执行完指令 1 和 2 后,线程切换为 B 线程执行
  3. B 执行完指令 1,2,3 后,更新 counter 为 1,线程切换为 A 执行
  4. A 执行指令 3,更新 counter 为 1

这种错误出现的原因,就在于 CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言级别

# 指令重排序导致的有序性问题

程序按照代码的先后顺序执行,称为有序性(Ordering)

为了充分利用 CPU 内部运算单元高速缓存,编译器和处理器常常会对指令进行重排序

  1. 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  2. 指令级并行的重排序:现代 CPU 采用指令级并行技术,如果不存在数据依赖,可以多条指令重叠执行
  3. 内存系统的重排序:由于 CPU 使用缓存和读/写缓冲区,使得 load/store 操作看上去可能是在乱序执行

Java 中重排序,主要分为两类,分别对应编译时和运行时,即编译期重排序运行期重排序,重排序满足以下两个条件:

  1. 遵守 as - if - serial 语义,即单线程环境下,重排序后的运行结果与顺序执行是相同的
  2. 不对存在数据依赖关系的指令进行重排序
  • 单核环境下:重排序是提高 CPU 运算速度的一种优化,保证结果与顺序执行相同(as - if - serial)
  • 多核环境下:如果一个线程的计算任务依赖另一个线程计算任务的中间结果,则代码的顺序性无法保证执行的顺序性,最终的结果也会不同于逻辑结果
/**
 * 错误案例 1:编译器级别 - 无依赖关系指令
 * 两个线程,一个执行 writer,一个执行 reader
 *  正常情况:A 执行完成后,才会执行 D
 *  重排序后:A 和 B 无依赖关系,因此 B 可能先于 A 执行,导致 D 也先于 A 执行
 *
 * 解决方案:使用 volatile 修饰 flag 保证有序性
 */
public class ReorderDemo {
    int counter = 0;
    boolean flag = false;

    public void writer() {
        counter = 1;  // A
        flag = true;  // B
    }

    public void reader() {
        if (flag) {   // C
            System.out.println(counter);  // D
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

/**
 * 错误案例 2:指令级别 - 双重检查创建单例对象
 * new 操作需要 3 个指令完成:
 *  1. 分配一块内存 M
 *  2. 在内存 M 上初始化 Singleton 对象
 *  3. 将 M 的地址赋值给 instance 变量
 *
 * 正常情况:
 *  1. 线程 A,线程 B 同时调用 getInstance()
 *  2. 同时发现 instance == null,竞争对 Singleton.class 加锁
 *  3,JVM 保证只有一个线程成功获得锁,假设 A 成功
 *  4. A new 一个 Singleton 实例,释放锁
 *  5. B 获得锁,且发现 instance != null,不会重复创建实例
 *
 * 重排序后:指令按照 1,3,2 执行
 *  1. 线程 A 获得锁,执行 new 语句,当执行完指令 3 时发生线程切换
 *  2. 线程 B 调用 getInstance(),执行第一个判断 instance == null
 *     发现 instance != null,直接返回 instance
 *     (注意:32 行才是出错的关键,synchronize 可以保证可见性和有序性,但是这针对同步块内的
 *          即,如果有线程 C 阻塞在 33,阻塞结束后,进入同步区,然后返回,是不会出现问题的)
 *  3. 此时 instance 是还未初始化的,一旦访问其成员变量,会触发空指针异常
 *
 * 解决方案:
 *  使用 volatile 修饰 instance,通过 happen-before 原则
 *      程序次序规则:指令 2  -> 指令 3,
 *      volatile 变量规则:指令 3 是对 instance 的写,指令 3 -> if (instance == null)
 *      => 指令 2,3 -> if (instance == null)
 */
public class Singleton {
    static Singleton instance;
    static Singleton getInstance(){
        if (instance == null) {
            synchronized(Singleton.class) {
                if (instance == null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

参考:

[1] 极客时间 | Java 并发编程实战 (opens new window)

Last Updated: 9/18/2020, 6:40:58 AM